크롤러·공격 요청을 탐지해 자동으로 차단하는 시스템 전체를 설명한다.
FilterAccessLog가 모든 요청을 가로채 아래 순서로 판정한다.
요청 도착 │ ├─[0] ipWhitelist 에 있나? ────────YES─► 아래 전부 건너뛰고 정상 처리 │ ├─[1] IpRateLimiter.isKnownBanned? ─YES─► tarpit(1~6분) + 403 │ (인메모리, DB 없음) │ ├─[·] IpRateLimiter.isKnownClean? ─YES─► [2] 를 건너뛴다 (60초 TTL) │ ├─[2] IpDeny DB 조회 ──────────────YES─► ban() 캐시 + tarpit(1~6분) + 403 │ (0·1 이 false이고 clean 캐시도 없을 때만 실행) │ ├─[3] UriAttackDetector.isAttack? ─YES─► ban() + IpDeny 등록(async) + tarpit(1~11분) + 403 │ ├─[4] IpRateLimiter.recordAndCheck? YES─► ban() + IpDeny 등록(async) + 즉시 403 │ └─[5] 정상 처리
[0] 화이트리스트: AhaWiki.ipWhitelist 에 있는 주소는 [1]~[4] 를 모두 건너뛴다. IpDeny 행이 남아 있어도 그렇다 — 그래서 차단을 푸는 가장 빠른 방법은 행을 지우는 것이 아니라 여기에 넣는 것이다. 예전에는 [2] 가 이 검사보다 먼저라 한 번 등록된 주소는 화이트리스트로도 풀 수 없었고, 서버 자신의 EIP 가 두 달 막혀 있었다. 루프백 세 주소는 설정이 아니라 logics.ApplicationConf.AlwaysWhitelisted 에 있어서 사이트 conf 가 지울 수 없다.[1] 인메모리 ban 확인: IpRateLimiter.knownBanned 셋을 조회한다. DB 없음. [2]~[4]에서 차단된 IP가 재요청하면 여기서 바로 잡힌다.[·] 인메모리 clean 캐시: knownClean 에 있으면 [2] 의 DB 조회를 건너뛴다. TTL 은 cleanTtlMs(현재 60초). 정상 방문자가 매 요청마다 IpDeny 를 조회하지 않게 하는 것이 목적이다.[2] IpDeny DB 조회: 서버 재시작 전에 등록된 영구 차단 IP를 처리한다. 조회 결과가 있으면 ban()을 호출해 이후 요청은 [1]에서 처리되도록 캐시한다. 결과가 없으면 markClean() 으로 60초 동안 knownClean 에 넣는다 — [·] 는 여기서만 채워진다.[3] UriAttackDetector: WordPress·phpinfo 등 알려진 공격 URI를 차단한다. 탐지 즉시 ban()을 호출해 actor가 DB에 쓰기 전 같은 IP의 재요청을 [1]에서 막는다. IpDeny 등록 메시지는 tarpit 이 끝난 뒤(1~11분 뒤)에야 actor 로 간다 — 그 사이 재시작하면 행이 남지 않는다. /public/·/assets/ 요청에서 잡힌 경우는 AccessLog 와 함께 IpDeny 등록도 건너뛴다(shouldSkipAccessLogUri).[4] IpRateLimiter.recordAndCheck: 고속 요청 또는 페이지만 긁는 패턴을 탐지한다. tarpit 없이 즉시 응답한다(고볼륨 봇에 tarpit을 걸면 서버 커넥션이 쌓임). 등록 메시지의 조건은 [3] 과 같다.after(delay, scheduler) + 지연 응답. 스레드는 잡히지 않지만 TCP 커넥션은 열려 있다. [1][2] 와 [3] 에 쓰고, [4] 만 tarpit 없이 바로 403 이다.logics.security.IpRateLimiter (@Singleton)
인메모리 셋(ConcurrentHashMap.newKeySet). 세 경로에서 추가된다.
recordAndCheck 탐지 시UriAttackDetector 탐지 시 (FilterAccessLog에서 직접 호출)IpDeny DB 조회 결과가 있을 때 (FilterAccessLog에서 직접 호출)서버 재시작 시 초기화된다. 이후 요청은 [2] DB 조회를 통해 다시 캐시된다.
WikiPage: /w/*HumanSignal: 그 외 전부 (/public/, /assets/, /api/links/, /api/me, /search 등)실제 브라우저는 /w/*와 함께 반드시 다른 요청을 섞어 보낸다.
순수 스크레이퍼는 /w/*만 요청하므로 HumanSignal이 누적되지 않는다.
임계값 넷은 IpRateLimiter 의 상수다. 괄호 안은 현재 값이다.
windowMs 안에 /w/* 가 rateThreshold 회 이상 (30초 / 30회)/w/* 가 botPageMin 회 이상이고 HumanSignal 이 botHumanSignalMin 회 미만 (5회 / 3회)둘째 패턴은 사람이 만든 도구도 문다. 배포 헬스체크가 /w/FrontPage 만 3초 간격으로 폴링해서 다섯 번째에 자기를 차단한 적이 있다 — Dev Deploying 의 "왜 이런 모양인가" 참고. 자동 요청을 짜는 쪽에서는 /w/ 밖의 URL 을 쓰거나 화이트리스트에 오르는 편이 낫다.
recordAndCheck 가 낮은 확률로 cleanup()을 실행해 만료된 windows 엔트리와 만료된 knownClean 엔트리를 제거한다. 확률은 그 함수 안의 Random.nextInt 인자(현재 1000번에 1번)다.
logics.security.UriAttackDetector
알려진 공격·탐색 URI 패턴을 in-memory로 판정한다.
startsWith 목록: /wp, /wordpress, /backup, /.env, /.git 등contains 목록: /wp-admin, /wp-login.php, /phpinfo.php, /xmlrpc.php 등.php 파일 경로 전반 ((?i)^/[a-z0-9._=-]{1,64}\.php...)models.tables.IpDeny
insert(ip, accessLog, reason): actor(ActorAccessLog)가 비동기로 호출한다.selectLatest(ip): 최신 차단 레코드 조회. FilterAccessLog에서 [0][1] 이 모두 false 이고 knownClean 에도 없을 때만 실행된다.deleteExpired: Retention 보다 오래된 레코드를 정리한다. 값과 그 이유는 상수 옆에 있다.보관 기간이 곧 차단 기간이다. selectLatest 는 행의 나이를 보지 않으므로, 행이 남아 있는 동안 그 주소는 계속 403 이다. 이 문서는 2026-08-03 에 값이 바뀐 뒤로도 "5년" 이라고 적고 있었다 — 숫자를 산문에 옮겨 적으면 이렇게 갈라진다. 차단을 당장 풀려면 ipWhitelist 에 넣는 편이 빠르다(맨 위 표에서 whitelist 가 IpDeny 조회 자체를 건너뛴다).
운영은 인스턴스 둘이 동시에 요청을 받는다(측정은 Dev Deploying). IpRateLimiter 의 창·knownBanned·knownClean 은 인스턴스마다 따로라서, 이 페이지의 흐름은 인스턴스 하나가 보는 것이다.
IpDeny 행뿐이다. [4] 는 곧바로 행을 쓰지만(actor, 비동기), 다른 인스턴스가 그 주소를 knownClean 에 넣어 두었다면 그것이 끝날 때까지(cleanTtlMs) DB 를 다시 보지 않는다. [3] 은 tarpit 이 끝난 뒤에야 행을 쓰므로, 그 몇 분 동안 다른 인스턴스는 그 주소의 공격 URI 가 아닌 요청을 정상으로 처리한다.행이 쓰이고 나면 양쪽이 모두 막으므로, 이 틈은 분 단위다. 차단 기간과 견줄 크기는 아니다.
app/filters/FilterAccessLog.scalaapp/logics/security/IpRateLimiter.scalaapp/logics/security/UriAttackDetector.scalaapp/models/tables/IpDeny.scalaapp/actors/ActorAccessLog.scala